Skip to content

ios: remove iroh entirely; the relay is the default connection method - #11177

Draft
lawrencecchen wants to merge 57 commits into
feat-relay-input-pipeliningfrom
feat-ios-remove-iroh
Draft

lawrencecchen wants to merge 57 commits into
feat-relay-input-pipeliningfrom
feat-ios-remove-iroh

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #10963. iOS no longer links IrohLib or dials, discovers, or configures iroh anywhere.

Method model. MobileConnectionMethod is now relay (default) and tailscale. The legacy automatic (iroh) and direct (an iroh dial allowlist, transportless without iroh) raw values no longer decode, which migrates those pairings to the relay default; explicit Tailscale choices persist. Debug loopback rides alongside the relay exactly as it rode alongside iroh: it is the same-machine dev lane simulator auto-pair depends on, not a fallback. The relay method is exclusive otherwise: persisted Tailscale/host-port rows never dial under it, and a relay pairing with no persisted route still dials via the synthesized route.

Deleted. The iroh/irx runtime compositions and release-gate target, zero-touch discovery, the broker Forget revoke (Forget now cleans local rows and backup tombstones only), the iroh warm-focus fast path, iroh settings/network UI, Direct-address UI, iroh plist/xcconfig keys, and the iroh test suites whose subjects are gone.

Mac side. Relay Remote Access defaults ON so a relay-default phone works out of the box; the toggle still provably closes the relay socket. The Mac's iroh HOSTING is untouched (old phone builds keep working); removing it is a later wave, as is deleting the iroh wire vocabulary from CMUXMobileCore, which the Mac still uses.

Copy. Onboarding offers Relay or Tailscale Only; Computers group Relay first; discovery copy teaches the relay; en+ja entries updated.

Test status. Model 339/339, relay transport 19/19, RPC compiles, iOS app archives green on the fleet. The CmuxMobileShell suite has failures that reproduce on the merge base (pre-sweep, relay-v2 head) — a baseline run is in flight to separate pre-existing failures from this branch's delta; the delta will be fixed on this branch before it leaves draft.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

iOS removes iroh entirely; the relay is the default connection method. Render-grid event frames gain negotiated zlib compression at subscribe, relay hops verify Stack access tokens locally against the project's JWKS (fresh-token connects drop from ~390ms to ~150-180ms), and startup dials the stored Mac before the auth bootstrap finishes (~0.25s vs ~1.24s launch-to-dial; a bootstrap resolving a different account or team supersedes the early dial).

Migration

  • MobileConnectionMethod shrinks to relay (default) and tailscale; stored automatic/direct values migrate to relay, explicit Tailscale persists.
  • Debug loopback rides alongside the relay as a same-machine dev lane; persisted Tailscale/host-port rows never dial under it.
  • Pairing attach URL grammars now embed the Mac device id (d), so fresh relay pairing on physical devices dials instead of ending at no-supported-route; old phones ignore the new item.
  • Mac Relay Remote Access defaults ON so relay-default phones pair out of the box; Mac iroh HOSTING is untouched so old phone builds keep working.
  • Admitted relay sessions now serve identity-bearing host status, fixing the relay connect failure; Forget cleans only local rows and backup tombstones (broker revoke gone); legacy iroh install markers keep device-registry ids stable across upgrades.
  • iroh settings/network and Direct-address UI, the release-gate target and its simulator-e2e CI job, zero-touch discovery, and the warm-focus fast path are deleted; stale iroh rows label as Legacy; onboarding and Computers UI now offer Relay or Tailscale Only.

Liveness, diagnostics, and relay internals

  • While the user sent terminal input within the last 10s, the silence budget drops from 9s to 3s and one failed probe triggers recovery; idle keeps the 9s/two-failure behavior.
  • The pass-level reconnect backoff marker now sets on any automatic dial, restoring the backoff record on a fully failed relay pass.
  • Reconnects that know the Mac's capabilities pipeline the event subscribe onto the same pre-admission batch as the workspace-list exchange, cutting a post-admission round trip; the learned set now persists device-locally (schema v12, never backed up), so cold launches pipeline too, and first pairings keep the sequential subscribe because the planner never guesses unlearned capabilities.
  • Relay roaming is make-before-break: a network path change dials a parallel replacement (one extra route lease, never a third) while the old session keeps serving, and the swap commits only once the replacement is admitted; a failed replacement leaves the old connection untouched, and a host that rejects the pipelined subscribe doesn't gate the swap (the fallback sequential subscribe completes it).
  • Relay object names gain a per-build instance tag: two tagged dev Mac builds on one machine no longer supersede each other's host socket; release lanes stay untagged so production object names are unchanged.
  • HostRelay frame forwarding is now zero-copy (cached attachments, a session-indexed socket map, in-place session-id rewriting), removing per-frame deserialization and re-encoding up to 256 KiB per message.
  • The launch dial no longer waits for the backup refresh: persisted rows serve while the restore settles in the background (empty scopes still wait), the refresh outcome feeds only the refreshed-route retry, and a merge that wrote rows reloads the published list so backup-only Macs appear. A fire-and-forget /healthz HEAD pre-warms the relay TLS session at composition start.
  • Pairing route rejections and startup timing spans land in the copyable debug log, and the stored-Mac reconnect preamble records every claim→dial stage so a headless test bounds the structural path at 250ms. Echo prediction behind the cmux.debug.echo-prediction toggle (default OFF) runs the full predict/confirm/back-off loop headlessly.
  • The startup coordinator moved into CmuxMobileShellModel so its claim-ordering tests run in CI; iroh-era reconnect tests were rewritten against the relay default, and the two remaining CmuxMobileShell suite reds reproduce identically on origin/main.
  • A benchmark of the reserved raw terminal channel concluded it is not built: the per-frame envelope tax (115-120µs for an 8KB delta, 508-564µs for a 40KB full frame) is dwarfed by the typed JSON decode both paths share.

Written for commit 99ca7e8. Summary will update on new commits.

Review in cubic

lawrence703 and others added 9 commits August 27, 2026 22:52
Deletes the iroh/irx runtime compositions, release-gate scenes, and
lanes from the app layer. Legacy iroh install markers stay readable via
MobileSameDeviceEvidenceProbe so device-registry ids survive upgrades.
…ault method

MobileConnectionMethod loses automatic (iroh) and direct (an iroh dial
allowlist); persisted values for both no longer decode, which migrates
those pairings to the relay default. Explicit tailscale persists. The
relay method dials one synthesized route, relay pairings dial with no
persisted route (the ticket carries the synthesized route), and the
reconnect gates treat relay pairings as always dialable.

Deleted: zero-touch iroh discovery, the iroh broker Forget revoke
(Forget now cleans local rows and backup tombstones only), the iroh
warm-focus fast path, iroh settings UI, direct-address UI, the iroh
release-gate target, and the iroh plist/xcconfig config keys.

Onboarding offers Relay or Tailscale Only. Computers group Relay first.
Copy that taught iroh discovery now teaches the relay.
Store tests cover the relay default and the automatic/direct raw-value
migration. Deleted the test suites whose subjects are gone (zero-touch
discovery, forget revoke pinning/scope, discovery invalidation); backup
team-routing and pending-delete replay keep their local-cleanup
coverage without the revoke fake. The Mac's Relay Remote Access setting
defaults on, because the relay is now the phone's default method and a
fresh pairing must work out of the box; the toggle still turns it off.
…n action, settings body split for the type checker)
…ix; drop iroh reconnect suites

The relay method's route set now includes supported debug-loopback
routes ahead of the synthesized relay route, exactly as loopback rode
alongside iroh: it is the same-machine dev lane simulator auto-pair
depends on, not a cross-method fallback, and production route sets
compile it out. Without this, relay-as-default excluded every scripted
loopback dial (and would have broken simulator pairing).

MobileSettingsView's body is split into settingsContent plus two
section helpers inside the struct, because the single inline expression
exceeded the archive-time type-checker budget.
For a relay-method pairing the stored reconnect dial set is debug
loopback (the same-machine dev lane) plus the synthesized relay route,
appended so a pairing with no persisted route still dials; persisted
Tailscale and host/port rows never dial under the relay method. The
stored-ticket dial branch keys on the first route's kind, so a
loopback-first set keeps the legacy dev dial and its bearer handling.
@vercel

vercel Bot commented Aug 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cmux166 Ready Ready Preview Sep 1, 2026 4:38pm UTC
cmux41 Ready Ready Preview Sep 1, 2026 4:38pm UTC

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Test attribution complete: the CmuxMobileShell local-suite failures (MobileMacConnectionPool, SecondaryInstanceAuthority, DismissSync, Coalescing, ReconnectRouteSelection) reproduce identically on origin/main (control test verified) and on the merge base — chronic, not from this branch. This branch's suite total is LOWER than the baseline (161 vs 205+ issue lines) with zero suite-level regressions. Known follow-up filed by this PR: the relay object name (v2::) carries no instance tag, so two tagged dev Mac builds on one machine contend for the host slot (last-wins); production single-build Macs are unaffected.

The connection report captured transport-level relay steps but every
shell-level route rejection (exchange error, incompatible build, tag
mismatch, undecodable status) logged only to os_log, which the report
cannot include. Each decision point in the pairing route loop now also
writes one line to the in-app debug ring, so a copied report names the
exact step that abandoned a route.
Root cause of the phone's relay connect failure: mobile.host.status is
credential-free, so the phone's status exchange overtook its own
in-flight admission and was answered with the identity-free public
status; the phone then abandoned the route for missing identity (over
iroh this race cannot happen because admission completes at the
handshake). The relay arm now awaits admission settlement (the
bounded wait in MobileHostRelayAdmission) and serves the same
identity-bearing status an admitted iroh session gets; an unadmitted
session still gets only the public status.
…ribe

A compressed frame is [0x01][zlib(envelope)]; the magic byte can never
begin a JSON envelope, so decoders handle old and new senders alike.
The phone opts in with event_compression=deflate at subscribe; the Mac
compresses each anchor's delta/full-frame envelope once (>=256 bytes,
only when it shrinks) and picks per connection, so a subscriber that
never asked keeps plain frames. Inflation is capped at the codec frame
limit (zip-bomb guard) and a frame that fails to inflate is dropped
like an unparseable envelope. Compression stays on the event fan-out
path, so the bounded queues, coalescing, and replay semantics that keep
reconnects lossless are untouched; a dedicated relay terminal channel
remains a follow-up since its residual win after compression is one
small JSON parse.
Stack access tokens are ES256 JWTs; both relay hops now verify them
locally instead of calling the Stack API. The worker fetches and caches
the project's JWKS per isolate (24h TTL, cooldown-limited refetch on an
unknown kid for key rotation) and only falls back to /users/me for
opaque non-JWT tokens. The Mac's verifier does the same with CryptoKit
(P-256, JOSE raw signatures), taking session admission from a
110-290ms Stack round trip to about a millisecond once the key set is
cached. Audience must equal the project id exactly: the :anon and
:restricted suffixes are user classes a relay host rejects. Measured
after the worker deploy: relay connect fell from ~390ms to ~150-180ms
with fresh tokens.
The 1.5s between launch and the first relay dial needs attribution
before restructuring the startup claim ordering; these marks split it
into UI mount, awaitBootstrapped, and reconnect-claim spans in the
copyable debug log.
@github-actions

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

…ache

iss must equal <stackBase>/api/v1/projects/<projectId> (real-token
verified), or its stack-auth<->hexclave rebrand alias host; the allow-set
is computed from the configured base URL. A mismatch is a definitive
invalid_token.

The Mac's fetched JWKS (public keys only, never tokens or verdicts) now
persists to UserDefaults with its fetch time and source URL, so the first
admission after app launch skips the ~110ms key fetch. 24h TTL and
refetch cooldown semantics unchanged.
…tion gating

The roaming tests scripted a connect fate PER TRANSPORT INSTANCE. The
pipelined connect-time subscribe added a second dial per candidate (its
beginSend's ensureConnected precedes the workspace-list exchange), so
the scripted .fail was consumed by the subscribe dial and the exchange
re-dialed an unscripted default-success transport: the replacement
exchange genuinely succeeded and the swap commit was CORRECT production
behavior. The fakes now model the NETWORK PATH (up / down / parked): a
dead path refuses every dial of the candidate, including the exchange
retry, exactly like the real wire, so the failure scenarios mean what
they say regardless of how many dials a candidate makes.

New pinned invariant test: a host that rejects the pipelined subscribe
must not gate (or fail) the roaming adoption exchange; the swap commits
and the fallback sequential subscribe completes validation.
lawrence703 and others added 9 commits August 31, 2026 15:40
…hannel

Replicates both wire paths for render-grid frames end to end:
today's JSON event envelope (emitRenderGridEvent splice + phone
dispatch's JSONSerialization parse + payload re-serialize + typed
decode) against a hypothetical channelTerminal binary framing
([version][flags][surfaceID][anchor][seq][payload]) that hands the
payload straight to the typed decoder.

Measured on an M-series Mac, release build, p50 of 300 runs:
8KB delta win 115-120us/frame, 40KB full frame win 508-564us/frame
(plain and zlib wire). Both are under the 1ms/delta threshold, so the
dedicated channel is not built; the envelope tax is dwarfed by the
typed JSONDecoder decode both paths share.
Cold-launch device logs show the reconnect claim at 0.14s but the first
relay dial only at 1.09s. The ~0.9s hole was the awaited per-user backup
fetch (network round trip + LWW merge) on the pre-dial path, pure waste
for a relay-method pairing whose dial route is synthesized at connect
time.

The launch reconnect now dials immediately from persisted state while
refreshFromBackup runs as a concurrent task. The refresh outcome still
feeds the existing freshReconnectRoutesAfterLocalFailure retry:
loadRefreshSnapshotIfNeeded awaits the task before its store read, so a
non-relay pairing whose local routes are unusable or stale waits for the
merge exactly as before, on the same generation/claim machinery. No
second reconnect path is introduced.

Tests pin both halves: a dialable pairing connects while the refresh is
still blocked, and a failed local dial's refreshed-route retry waits for
the blocked refresh and dials the route the merge produced.
…ubscribe

The connect-time pipelined mobile.events.subscribe only fires with a
learned capability snapshot, which lived solely in the in-process
connection pool. Every cold launch therefore paid a sequential
post-adoption subscribe (welcome 1.31s -> subscribe_ok 1.69s measured).

Store: paired-mac schema v12 adds a device-local learned_capabilities
column (same additive column-add style as v10 connection_method / v11
direct_addresses), setLearnedCapabilities never bumps LWW freshness, and
the value is excluded from Codable state so it never rides the account
backup. Decorators (build-scoped, team-scoped, backing-up, compatible)
forward it like setDirectAddresses.

Shell: capabilities learned post-adoption (and on mid-session host-status
changes) update an in-memory per-device index and persist to the store;
the connect-time snapshot read falls back to that index when the live
pool has no entry, so the first connect of a process pipelines the exact
last-known request. The live pool still wins when present, and a stale
persisted set costs only the ordinary idempotent corrective re-subscribe.

Tests: store round trip without LWW bump, v11->v12 migration on a seeded
v11 database, and a cold-launch shell test proving a stale persisted set
pipelines, corrects within one round trip, and persists the refreshed
set (mirroring staleLearnedCapabilitiesReassertAndNextReconnectPipelinesExactly).
The first wss dial paid DNS + TCP + TLS on the connect path. At
composition start the app now fires ONE fire-and-forget HEAD to the
resolved relay origin's /healthz (RelayConnectAuth.resolvedRelayURL,
wss swapped to https, unauthenticated worker route) so that state is
already pooled when the dial starts.

Session identity is the whole point and is now explicit: cmuxApp pins
relayURLSession = URLSession.shared, passes it to
RelayConnection.factory(urlSession:) (the session whose webSocketTask
the relay transport dials with), and fires the pre-warm on that same
instance, since URLSession pools connection state per session.

No retries, no error surfacing, 5s request timeout. Privacy: the
request carries no token, cookie, or identifying header; it reveals to
the relay origin, which the app dials anyway, only that a launch
happened. Tests pin the healthz URL derivation (wss->https, ws->http
for loopback dev, explicit port kept, query/fragment dropped, unknown
scheme fails closed).
The iOS app never wires independentEventByteStreamProvider or
artifactLaneProvider (cmuxApp.swift constructs CMUXMobileRuntime without
them), and both factories were additionally gated on route.kind == .iroh,
which production supportedRouteKinds no longer contains. Deletes the
session's optional-lane negotiation (prepareIndependentServerEvents, the
event_transport advertisement, supportsPipelinedInitialEventSubscribe),
openArtifactLane and the chat artifact lane fetch path (which could only
mint a descriptor RPC and then always fall back), the lifecycle-gate
admissions for both lanes, the runtime slots, and the
irohDirectOnlyDialCandidates parameter no caller passed. dispatch(frame:)
moves to MobileCoreRPCSession+EventDispatch.swift because the control
reader still uses it. The CmxByteTransportRequest core field stays; the
Mac still hosts these lanes for old phones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…overy

automaticIrohReconnectIsBlocked guards the shared automatic-reconnect
backoff owner that relay and Tailscale redials use too, so it becomes
automaticReconnectIsBlocked. secondaryIrohDiscoveryPending is the generic
one-shot account-backup discovery intent, so it becomes
secondaryPeerDiscoveryPending; its caller-less preserve helper is deleted.
The terminal-lane resync reasons drop their iroh_ prefix (debug-log only;
no test or analytics pins them), and supportedRoutes' unreachable trailing
iroh/loopback filter goes away by switching exhaustively over the two-case
connection method. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Persisted iroh rows still decode and display as inert entries, but the
kind label read "Iroh", naming a transport this app can no longer dial.
The per-Computer route diagnostics and the Settings active-transport row
now label the .iroh kind "Legacy" (mobile.connections.method.legacy /
mobile.settings.activeTransport.legacy, en + ja). Removes the 119 app and
103 CmuxMobileShellUI catalog keys that belonged to deleted iroh
settings/diagnostics views (verified unreferenced by grep over Packages
and ios; mobile.iroh.private.custom.unnamedMac stays, LocalizedMacBuildLabel
still uses it), and rewrites comments that described iroh runtime behavior
that no longer exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The in-app simulator release-gate runner was deleted earlier on this
branch, so SIMCTL_CHILD_CMUX_IROH_RELEASE_GATE_MODE/_SCENARIO and
SIMCTL_CHILD_CMUX_IROH_DISABLE_RELAY_CREDENTIAL_REFRESH had no reader
(verified by grep over Packages, ios, and Sources), and --iroh-release-gate
launched an app that silently ignored the gate. Removes the flag, its env
threading, and the harness that existed only to drive that runner
(run-iroh-release-gate.sh, its targets/verify libs, its .mjs suite, and
the workflow's simulator-e2e job). The workflow keeps the Mac-side
Tailscale version-skew job: the Mac still hosts iroh for old phones.
mobile-attach.sh's legacy iroh acceptance is untouched; mobile-attach
tests keep the launcher/ios-reload assertions that were not gate-specific
and drop only the gate-driven ones (including the simulator test that used
the flag to force the physical-device ticket policy). bash -n, actionlint,
and node --test mobile-attach.test.mjs (40/40) pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrence703 and others added 2 commits August 31, 2026 17:55
With --iroh-release-gate gone the readiness gate runs for every cursor,
so the elif that deferred verification to the in-app release-gate runner
could never fire. bash -n and node --test mobile-attach.test.mjs pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pass-level transient reconnect backoff was gated on having dialed
an IROH route, so it silently stopped firing when iroh dialing was
removed. Any automatic candidate dial now sets the flag (the relay is
the automatic transport), restoring the backoff record on a fully
failed pass. The AutoConnectMigration flow is deliberately KEPT: its
consumers are live and iroh-era users updating to this build are
exactly who needs the connection-changed explanation.
…→dial headlessly

On a cold launch the reconnect claim lands at ~0.1s but the first relay
dial starts at ~1.4s, and no existing mark explained the middle. Every
preamble stage now records its offset from the claim instant into
storedMacReconnectPreambleStages (mirrored to the device debug log), so
one launch names the stage that absorbs the gap.

A headless test drives the real preamble against a SQLite paired-Mac
store and a scripted transport and bounds call→transport-dial at 250ms;
it measures ~4ms, so a regression in the structural path is caught per
commit and the remaining device gap is known to be environmental.

Claude-Session: https://claude.ai/code/session_016U5GVUPKNK56M5Ca1kXFxk
…de loadAll

BackingUpPairedMacStore.loadAll runs restoreIfNeeded first, and on a
fresh process that awaits the same restore the concurrent backup refresh
just started. The reconnect preamble therefore blocks at store-reads for
the full backup round trip; the headless timeline stops at
backup-refresh-spawned until the fetch is released.

Claude-Session: https://claude.ai/code/session_016U5GVUPKNK56M5Ca1kXFxk
…estore

BackingUpPairedMacStore.restoreIfNeeded awaited the in-flight restore on
every first read of a scope, so on a cold launch the reconnect preamble,
the instance-authority check, and the ticket persist before .connected
each paid the backup round trip, measured as ~1.3s of the claim→dial gap
on the phone. The concurrent refresh landed in wave 3 never helped
because the store awaited the same restore two calls later.

A scope with rows on disk now returns them at once and settles the
restore in a background task (same bookkeeping, idempotent); a scope
with nothing persisted still waits, since the backup is its only source.
The headless cold-launch test goes green, and the fallback test pins the
empty-scope wait.

Claude-Session: https://claude.ai/code/session_016U5GVUPKNK56M5Ca1kXFxk
Reads no longer wait for the restore, so a Mac paired or forgotten on
another device reached the published list only on the next list load.
The cold-launch refresh now reports whether the merge wrote rows and the
reconnect reloads the list only then, so stores without change
accounting (and every existing fake) add no read. The restore-gate test
asserts the backup-only Mac appears without a pull; the preamble bound is
a 1s tripwire (710ms observed under a loaded parallel run), the gated
test is the gate.

Claude-Session: https://claude.ai/code/session_016U5GVUPKNK56M5Ca1kXFxk

This branch was successfully deployed

2 active deployments
Preview – cmux41 — 99ca7e81 Deployed Sep 1, 2026 by vercel[bot]
Preview – cmux166 — 99ca7e81 Deployed Sep 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ios The iOS app and mobile clients area: remote cmux ssh, remote daemon, tunnels, device pairing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants